/**
 * @license
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
import {util} from '../../../scripts/util.js';

const WHOLE_FILE = -1;

const DiffSide = {
  LEFT: 'left',
  RIGHT: 'right',
};

const DiffHighlights = {
  ADDED: 'edit_b',
  REMOVED: 'edit_a',
};

/**
 * The maximum size for an addition or removal chunk before it is broken down
 * into a series of chunks that are this size at most.
 *
 * Note: The value of 120 is chosen so that it is larger than the default
 * _asyncThreshold of 64, but feel free to tune this constant to your
 * performance needs.
 */
const MAX_GROUP_SIZE = 120;

/**
 * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
 *
 * Glossary:
 * - "chunk": A single `DiffContent` as returned by the API.
 * - "group": A single `GrDiffGroup` as used for rendering.
 * - "common" chunk/group: A chunk/group that should be considered unchanged
 *   for diffing purposes. This can mean its either actually unchanged, or it
 *   has only whitespace changes.
 * - "key location": A line number and side of the diff that should not be
 *   collapsed e.g. because a comment is attached to it, or because it was
 *   provided in the URL and thus should be visible
 * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
 *   or cannot be collapsed because it contains a key location
 *
 * Here a a number of tasks this processor performs:
 *  - splitting large chunks to allow more granular async rendering
 *  - adding a group for the "File" pseudo line that file-level comments can
 *    be attached to
 *  - replacing common parts of the diff that are outside the user's
 *    context setting and do not have comments with a group representing the
 *    "expand context" widget. This may require splitting a chunk/group so
 *    that the part that is within the context or has comments is shown, while
 *    the rest is not.
 *
 * @extends PolymerElement
 */
class GrDiffProcessor extends GestureEventListeners(
    LegacyElementMixin(
        PolymerElement)) {
  static get is() { return 'gr-diff-processor'; }

  static get properties() {
    return {

      /**
       * The amount of context around collapsed groups.
       */
      context: Number,

      /**
       * The array of groups output by the processor.
       */
      groups: {
        type: Array,
        notify: true,
      },

      /**
       * Locations that should not be collapsed, including the locations of
       * comments.
       */
      keyLocations: {
        type: Object,
        value() { return {left: {}, right: {}}; },
      },

      /**
       * The maximum number of lines to process synchronously.
       */
      _asyncThreshold: {
        type: Number,
        value: 64,
      },

      /** @type {?number} */
      _nextStepHandle: Number,
      /**
       * The promise last returned from `process()` while the asynchronous
       * processing is running - `null` otherwise. Provides a `cancel()`
       * method that rejects it with `{isCancelled: true}`.
       *
       * @type {?Object}
       */
      _processPromise: {
        type: Object,
        value: null,
      },
      _isScrolling: Boolean,
    };
  }

  /** @override */
  attached() {
    super.attached();
    this.listen(window, 'scroll', '_handleWindowScroll');
  }

  /** @override */
  detached() {
    super.detached();
    this.cancel();
    this.unlisten(window, 'scroll', '_handleWindowScroll');
  }

  _handleWindowScroll() {
    this._isScrolling = true;
    this.debounce('resetIsScrolling', () => {
      this._isScrolling = false;
    }, 50);
  }

  /**
   * Asynchronously process the diff chunks into groups. As it processes, it
   * will splice groups into the `groups` property of the component.
   *
   * @param {!Array<!Gerrit.DiffChunk>} chunks
   * @param {boolean} isBinary
   *
   * @return {!Promise<!Array<!Object>>} A promise that resolves with an
   *     array of GrDiffGroups when the diff is completely processed.
   */
  process(chunks, isBinary) {
    // Cancel any still running process() calls, because they append to the
    // same groups field.
    this.cancel();

    this.groups = [];
    this.push('groups', this._makeFileComments());

    // If it's a binary diff, we won't be rendering hunks of text differences
    // so finish processing.
    if (isBinary) { return Promise.resolve(); }

    this._processPromise = util.makeCancelable(
        new Promise(resolve => {
          const state = {
            lineNums: {left: 0, right: 0},
            chunkIndex: 0,
          };

          chunks = this._splitLargeChunks(chunks);
          chunks = this._splitCommonChunksWithKeyLocations(chunks);

          let currentBatch = 0;
          const nextStep = () => {
            if (this._isScrolling) {
              this._nextStepHandle = this.async(nextStep, 100);
              return;
            }
            // If we are done, resolve the promise.
            if (state.chunkIndex >= chunks.length) {
              resolve();
              this._nextStepHandle = null;
              return;
            }

            // Process the next chunk and incorporate the result.
            const stateUpdate = this._processNext(state, chunks);
            for (const group of stateUpdate.groups) {
              this.push('groups', group);
              currentBatch += group.lines.length;
            }
            state.lineNums.left += stateUpdate.lineDelta.left;
            state.lineNums.right += stateUpdate.lineDelta.right;

            // Increment the index and recurse.
            state.chunkIndex = stateUpdate.newChunkIndex;
            if (currentBatch >= this._asyncThreshold) {
              currentBatch = 0;
              this._nextStepHandle = this.async(nextStep, 1);
            } else {
              nextStep.call(this);
            }
          };

          nextStep.call(this);
        }));
    return this._processPromise
        .finally(() => { this._processPromise = null; });
  }

  /**
   * Cancel any jobs that are running.
   */
  cancel() {
    if (this._nextStepHandle != null) {
      this.cancelAsync(this._nextStepHandle);
      this._nextStepHandle = null;
    }
    if (this._processPromise) {
      this._processPromise.cancel();
    }
  }

  /**
   * Process the next uncollapsible chunk, or the next collapsible chunks.
   *
   * @param {!Object} state
   * @param {!Array<!Object>} chunks
   * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
   */
  _processNext(state, chunks) {
    const firstUncollapsibleChunkIndex =
        this._firstUncollapsibleChunkIndex(chunks, state.chunkIndex);
    if (firstUncollapsibleChunkIndex === state.chunkIndex) {
      const chunk = chunks[state.chunkIndex];
      return {
        lineDelta: {
          left: this._linesLeft(chunk).length,
          right: this._linesRight(chunk).length,
        },
        groups: [this._chunkToGroup(
            chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
        newChunkIndex: state.chunkIndex + 1,
      };
    }

    return this._processCollapsibleChunks(
        state, chunks, firstUncollapsibleChunkIndex);
  }

  _linesLeft(chunk) {
    return chunk.ab || chunk.a || [];
  }

  _linesRight(chunk) {
    return chunk.ab || chunk.b || [];
  }

  _firstUncollapsibleChunkIndex(chunks, offset) {
    let chunkIndex = offset;
    while (chunkIndex < chunks.length &&
        this._isCollapsibleChunk(chunks[chunkIndex])) {
      chunkIndex++;
    }
    return chunkIndex;
  }

  _isCollapsibleChunk(chunk) {
    return (chunk.ab || chunk.common) && !chunk.keyLocation;
  }

  /**
   * Process a stretch of collapsible chunks.
   *
   * Outputs up to three groups:
   *  1) Visible context before the hidden common code, unless it's the
   *     very beginning of the file.
   *  2) Context hidden behind a context bar, unless empty.
   *  3) Visible context after the hidden common code, unless it's the very
   *     end of the file.
   *
   * @param {!Object} state
   * @param {!Array<Object>} chunks
   * @param {number} firstUncollapsibleChunkIndex
   * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
   */
  _processCollapsibleChunks(
      state, chunks, firstUncollapsibleChunkIndex) {
    const collapsibleChunks = chunks.slice(
        state.chunkIndex, firstUncollapsibleChunkIndex);
    const lineCount = collapsibleChunks.reduce(
        (sum, chunk) => sum + this._commonChunkLength(chunk), 0);

    let groups = this._chunksToGroups(
        collapsibleChunks,
        state.lineNums.left + 1,
        state.lineNums.right + 1);

    if (this.context !== WHOLE_FILE) {
      const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
      const hiddenEnd = lineCount - (
        firstUncollapsibleChunkIndex === chunks.length ?
          0 : this.context);
      groups = GrDiffGroup.hideInContextControl(
          groups, hiddenStart, hiddenEnd);
    }

    return {
      lineDelta: {
        left: lineCount,
        right: lineCount,
      },
      groups,
      newChunkIndex: firstUncollapsibleChunkIndex,
    };
  }

  _commonChunkLength(chunk) {
    console.assert(chunk.ab || chunk.common);
    console.assert(
        !chunk.a || (chunk.b && chunk.a.length === chunk.b.length),
        `common chunk needs same number of a and b lines: `, chunk);
    return this._linesLeft(chunk).length;
  }

  /**
   * @param {!Array<!Object>} chunks
   * @param {number} offsetLeft
   * @param {number} offsetRight
   * @return {!Array<!Object>} (GrDiffGroup)
   */
  _chunksToGroups(chunks, offsetLeft, offsetRight) {
    return chunks.map(chunk => {
      const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
      const chunkLength = this._commonChunkLength(chunk);
      offsetLeft += chunkLength;
      offsetRight += chunkLength;
      return group;
    });
  }

  /**
   * @param {!Object} chunk
   * @param {number} offsetLeft
   * @param {number} offsetRight
   * @return {!Object} (GrDiffGroup)
   */
  _chunkToGroup(chunk, offsetLeft, offsetRight) {
    const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
    const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
    const group = new GrDiffGroup(type, lines);
    group.keyLocation = chunk.keyLocation;
    group.dueToRebase = chunk.due_to_rebase;
    group.ignoredWhitespaceOnly = chunk.common;
    return group;
  }

  _linesFromChunk(chunk, offsetLeft, offsetRight) {
    if (chunk.ab) {
      return chunk.ab.map((row, i) => this._lineFromRow(
          GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
    }
    let lines = [];
    if (chunk.a) {
      // Avoiding a.push(...b) because that causes callstack overflows for
      // large b, which can occur when large files are added removed.
      lines = lines.concat(this._linesFromRows(
          GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
          chunk[DiffHighlights.REMOVED]));
    }
    if (chunk.b) {
      // Avoiding a.push(...b) because that causes callstack overflows for
      // large b, which can occur when large files are added removed.
      lines = lines.concat(this._linesFromRows(
          GrDiffLine.Type.ADD, chunk.b, offsetRight,
          chunk[DiffHighlights.ADDED]));
    }
    return lines;
  }

  /**
   * @param {string} lineType (GrDiffLine.Type)
   * @param {!Array<string>} rows
   * @param {number} offset
   * @param {?Array<!Gerrit.IntralineInfo>=} opt_intralineInfos
   * @return {!Array<!Object>} (GrDiffLine)
   */
  _linesFromRows(lineType, rows, offset, opt_intralineInfos) {
    const grDiffHighlights = opt_intralineInfos ?
      this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
    return rows.map((row, i) => this._lineFromRow(
        lineType, offset, offset, row, i, grDiffHighlights));
  }

  /**
   * @param {string} type (GrDiffLine.Type)
   * @param {number} offsetLeft
   * @param {number} offsetRight
   * @param {string} row
   * @param {number} i
   * @param {!Array<!Object>=} opt_highlights
   * @return {!Object} (GrDiffLine)
   */
  _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
    const line = new GrDiffLine(type);
    line.text = row;
    if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
    if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
    if (opt_highlights) {
      line.hasIntralineInfo = true;
      line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
    } else {
      line.hasIntralineInfo = false;
    }
    return line;
  }

  _makeFileComments() {
    const line = new GrDiffLine(GrDiffLine.Type.BOTH);
    line.beforeNumber = GrDiffLine.FILE;
    line.afterNumber = GrDiffLine.FILE;
    return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
  }

  /**
   * Split chunks into smaller chunks of the same kind.
   *
   * This is done to prevent doing too much work on the main thread in one
   * uninterrupted rendering step, which would make the browser unresponsive.
   *
   * Note that in the case of unmodified chunks, we only split chunks if the
   * context is set to file (because otherwise they are split up further down
   * the processing into the visible and hidden context), and only split it
   * into 2 chunks, one max sized one and the rest (for reasons that are
   * unclear to me).
   *
   * @param {!Array<!Gerrit.DiffChunk>} chunks Chunks as returned from the server
   * @return {!Array<!Gerrit.DiffChunk>} Finer grained chunks.
   */
  _splitLargeChunks(chunks) {
    const newChunks = [];

    for (const chunk of chunks) {
      if (!chunk.ab) {
        for (const subChunk of this._breakdownChunk(chunk)) {
          newChunks.push(subChunk);
        }
        continue;
      }

      // If the context is set to "whole file", then break down the shared
      // chunks so they can be rendered incrementally. Note: this is not
      // enabled for any other context preference because manipulating the
      // chunks in this way violates assumptions by the context grouper logic.
      if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
        // Split large shared chunks in two, where the first is the maximum
        // group size.
        newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
        newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
      } else {
        newChunks.push(chunk);
      }
    }
    return newChunks;
  }

  /**
   * In order to show key locations, such as comments, out of the bounds of
   * the selected context, treat them as separate chunks within the model so
   * that the content (and context surrounding it) renders correctly.
   *
   * @param {!Array<!Object>} chunks DiffContents as returned from server.
   * @return {!Array<!Object>} Finer grained DiffContents.
   */
  _splitCommonChunksWithKeyLocations(chunks) {
    const result = [];
    let leftLineNum = 1;
    let rightLineNum = 1;

    for (const chunk of chunks) {
      // If it isn't a common chunk, append it as-is and update line numbers.
      if (!chunk.ab && !chunk.common) {
        if (chunk.a) {
          leftLineNum += chunk.a.length;
        }
        if (chunk.b) {
          rightLineNum += chunk.b.length;
        }
        result.push(chunk);
        continue;
      }

      if (chunk.common && chunk.a.length != chunk.b.length) {
        throw new Error(
            'DiffContent with common=true must always have equal length');
      }
      const numLines = this._commonChunkLength(chunk);
      const chunkEnds = this._findChunkEndsAtKeyLocations(
          numLines, leftLineNum, rightLineNum);
      leftLineNum += numLines;
      rightLineNum += numLines;

      if (chunk.ab) {
        result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
            .map(({lines, keyLocation}) =>
              Object.assign({}, chunk, {ab: lines, keyLocation})));
      } else if (chunk.common) {
        const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
        const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
        result.push(...aChunks.map(({lines, keyLocation}, i) =>
          Object.assign(
              {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
      }
    }

    return result;
  }

  /**
   * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
   *   new chunk ends, including whether it's a key location.
   */
  _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
    const result = [];
    let lastChunkEnd = 0;
    for (let i=0; i<numLines; i++) {
      // If this line should not be collapsed.
      if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
          this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
        // If any lines have been accumulated into the chunk leading up to
        // this non-collapse line, then add them as a chunk and start a new
        // one.
        if (i > lastChunkEnd) {
          result.push({offset: i, keyLocation: false});
          lastChunkEnd = i;
        }

        // Add the non-collapse line as its own chunk.
        result.push({offset: i + 1, keyLocation: true});
      }
    }

    if (numLines > lastChunkEnd) {
      result.push({offset: numLines, keyLocation: false});
    }

    return result;
  }

  _splitAtChunkEnds(lines, chunkEnds) {
    const result = [];
    let lastChunkEndOffset = 0;
    for (const {offset, keyLocation} of chunkEnds) {
      result.push(
          {lines: lines.slice(lastChunkEndOffset, offset), keyLocation});
      lastChunkEndOffset = offset;
    }
    return result;
  }

  /**
   * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
   * for rendering.
   *
   * @param {!Array<string>} rows
   * @param {!Array<!Gerrit.IntralineInfo>} intralineInfos
   * @return {!Array<!Object>} (GrDiffLine.Highlight)
   */
  _convertIntralineInfos(rows, intralineInfos) {
    let rowIndex = 0;
    let idx = 0;
    const normalized = [];
    for (const [skipLength, markLength] of intralineInfos) {
      let line = rows[rowIndex] + '\n';
      let j = 0;
      while (j < skipLength) {
        if (idx === line.length) {
          idx = 0;
          line = rows[++rowIndex] + '\n';
          continue;
        }
        idx++;
        j++;
      }
      let lineHighlight = {
        contentIndex: rowIndex,
        startIndex: idx,
      };

      j = 0;
      while (line && j < markLength) {
        if (idx === line.length) {
          idx = 0;
          line = rows[++rowIndex] + '\n';
          normalized.push(lineHighlight);
          lineHighlight = {
            contentIndex: rowIndex,
            startIndex: idx,
          };
          continue;
        }
        idx++;
        j++;
      }
      lineHighlight.endIndex = idx;
      normalized.push(lineHighlight);
    }
    return normalized;
  }

  /**
   * If a group is an addition or a removal, break it down into smaller groups
   * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
   * or a delta it is returned as the single element of the result array.
   *
   * @param {!Gerrit.DiffChunk} chunk A raw chunk from a diff response.
   * @return {!Array<!Array<!Object>>}
   */
  _breakdownChunk(chunk) {
    let key = null;
    if (chunk.a && !chunk.b) {
      key = 'a';
    } else if (chunk.b && !chunk.a) {
      key = 'b';
    } else if (chunk.ab) {
      key = 'ab';
    }

    if (!key) { return [chunk]; }

    return this._breakdown(chunk[key], MAX_GROUP_SIZE)
        .map(subChunkLines => {
          const subChunk = {};
          subChunk[key] = subChunkLines;
          if (chunk.due_to_rebase) {
            subChunk.due_to_rebase = true;
          }
          return subChunk;
        });
  }

  /**
   * Given an array and a size, return an array of arrays where no inner array
   * is larger than that size, preserving the original order.
   *
   * @param {!Array<T>} array
   * @param {number} size
   * @return {!Array<!Array<T>>}
   * @template T
   */
  _breakdown(array, size) {
    if (!array.length) { return []; }
    if (array.length < size) { return [array]; }

    const head = array.slice(0, array.length - size);
    const tail = array.slice(array.length - size);

    return this._breakdown(head, size).concat([tail]);
  }
}

customElements.define(GrDiffProcessor.is, GrDiffProcessor);
